Skip to content

feat: Automatically bound virtualizer visible rectangle to window viewport#9784

Merged
devongovett merged 5 commits intomainfrom
virtualizer-window
Mar 17, 2026
Merged

feat: Automatically bound virtualizer visible rectangle to window viewport#9784
devongovett merged 5 commits intomainfrom
virtualizer-window

Conversation

@devongovett
Copy link
Member

Closes #3965, closes #7820

This enables virtualized components to be placed within ancestor scrolling elements, including the entire page, and still virtualize their children automatically, without needing a bounded height on the virtualizer itself. It works by intersecting the visible rectangle with the window viewport, accounting for the scroll position of all ancestor elements. We use getBoundingClientRect to get the offset of the element relative to the viewport, and bound by the window size. We do not take the sizes of all ancestor elements into account for performance reasons, so there may be a few more elements rendered than absolutely necessary in some cases, but never more than the height of the window so this shouldn't be a big issue. This means it "just works" without any configuration of which element is scrollable.

Test instructions

Test new S2 TableView stories

// Attach a document-level capturing scroll listener so we can account for scrollable ancestors.
useEffect(() => {
document.addEventListener('scroll', onScroll, true);
return () => document.removeEventListener('scroll', onScroll, true);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using a capturing scroll listener seems better than attaching scroll listeners to all ancestor elements, but does mean we will receive events for potentially unrelated elements. The nodeContains check inside onScroll should account for this and quickly return early, but we should do some performance testing here.

@rspbot
Copy link

rspbot commented Mar 11, 2026

// If scrolling is not currently prevented then we aren't in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view
// Also ignore in chrome because of this bug: https://issues.chromium.org/issues/40074749
if (!isScrollPrevented && !isChrome()) {
if (!isScrollPrevented) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixes scroll into view when table is taller than the viewport. Underlying chrome bug is now fixed: https://issues.chromium.org/issues/40074749

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, why did the shim not work correctly there? Could maybe be addressed in #9780?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't dive too deep into it, but I think it's the same issue Daniel was addressing.

@rspbot
Copy link

rspbot commented Mar 11, 2026

<Context.Provider value={{state, dropState}}>
<Virtualizer
{...mergeProps(collectionProps, listBoxProps)}
onScroll={undefined}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need these? At a glance the Virtualizer type has this as optional

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there was a type error because collectionProps and listBoxProps have HTMLAttributes, which uses React's onScroll type, which is a UIEvent. But Virtualizer needs the native scroll event, which is just Event (idk why React is different). This was not caught before because the onScroll listener inside ScrollView was untyped (e was implicitly any).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, this could be a breaking change in the way its handled here? Before, we invoked the callback twice actually, once with a native event and once in our callback. Now the user wouldn't be receiving a UIEvent instance anymore.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was already true but was not correctly reflected in the types

Copy link
Contributor

@nwidynski nwidynski Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not what I meant. Your change to useScrollView L74 pulls this prop out from otherProps, where as before it was both passthrough'd AND invoked via props.onScroll. This means a user relying on receiving a UIEvent wont do so anymore after this change.

I specifically excluded onScroll from being passed to useScrollView in #9115 because of that. Or what am I missing? Why was this already true?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh that's true but I checked that collectionProps and listBoxProps do not have an onScroll handler

Copy link
Contributor

@nwidynski nwidynski Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true, but someone may be using react-arias Virtualizer or ScrollView independently. In any way, I just wanted to raise the issue because I remembered being bummed about having to make that backwards compatible 😅. It might not be a big deal to break, idk.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that resizing the window at all now breaks the S2 Table rendering. Haven't pulled it down locally, but I remember there were issues in the past with when to call updateSizeEvent

Image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops, I was creating a Rect instead of a Size and typescript wasn't catching it. 🤦

@rspbot
Copy link

rspbot commented Mar 12, 2026

@rspbot
Copy link

rspbot commented Mar 12, 2026

## API Changes

react-aria-components

/react-aria-components:TreeEmptyStateRenderProps

-TreeEmptyStateRenderProps {
-  allowsDragging: boolean
-  isFocusVisible: boolean
-  isFocused: boolean
-  selectionMode: SelectionMode
-  state: TreeState<unknown>
-}

@react-aria/utils

/@react-aria/utils:getNonce

-getNonce {
-  doc?: Document
-  returnVal: undefined
-}

@react-aria/virtualizer

/@react-aria/virtualizer:Virtualizer

 Virtualizer <O, T extends {}, V extends ReactNode> extends HTMLAttributes {
   children: (string, {}) => ReactNode
   collection: Collection<{}>
   isLoading?: boolean
   layout: Layout<{}, O>
   layoutOptions?: O
   onLoadMore?: () => void
+  onScroll?: (Event) => void
   persistedKeys?: Set<Key> | null
   renderWrapper?: RenderWrapper<{}, ReactNode>
   scrollDirection?: 'horizontal' | 'vertical' | 'both'
 }

/@react-aria/virtualizer:ScrollView

 ScrollView extends HTMLAttributes {
   children?: ReactNode
   contentSize: Size
   innerStyle?: CSSProperties
+  onScroll?: (Event) => void
   onScrollEnd?: () => void
   onScrollStart?: () => void
   onVisibleRectChange: (Rect) => void
   scrollDirection?: 'horizontal' | 'vertical' | 'both'


let updateVisibleRect = useCallback(() => {
// Intersect the window viewport with the scroll view itself to find the actual visible rectangle.
// This allows virtualized components to have unbounded height but still virtualize when scrolled with the page.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oo, no more required explicit height, nice!
I assume it applies to widths as well

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LFDanLu I think this should simplify our tests as well, we can more easily mock the virtualizer size vs the item sizes

Copy link
Contributor

@nwidynski nwidynski Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, the VIRT_ON flag breaks in certain scenarios. I addressed these cases with further improvements specific to virtualizer in test environments within #9115.

let visibleRect = new Rect(
state.viewportOffset.x + state.scrollPosition.x,
state.viewportOffset.y + state.scrollPosition.y,
Math.max(0, Math.min(state.size.width - state.viewportOffset.x, state.viewportSize.width)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when is this negative?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

viewportOffset may be greater than the size of the scrollview (when it is entirely scrolled out of view)

Comment on lines +135 to +139
let scrollLeft = getScrollLeft(target, direction);
state.scrollPosition = new Point(
Math.max(0, Math.min(scrollLeft, contentSize.width - state.size.width)),
Math.max(0, Math.min(scrollTop, contentSize.height - state.size.height))
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, this logic does not work in various edge cases. See #9115 for changes done to getScrollOffset.ts and getOffsetType.ts.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the logic has not changed in this PR, but what are the cases? I couldn't easily tell from the PR.

Copy link
Contributor

@nwidynski nwidynski Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clamping needs to be aware of the offset type, due to overscroll. I moved clamping into getScrollLeft and also added the same for getScrollTop, because of flex: <row/col>-reverse.

For example, in RTL layouts, we can get a negative float here, due to overscroll at the lower boundary.

Lastly, I noted that we probably should be more strict about these utilities being used everywhere we access or set scroll offsets. Often times there are subtle bugs introduced because of not doing so. One of the reasons why moving scroll observation into utils makes sense imo 😉

// Track the size of the entire window viewport, which is used to bound the size of the virtualizer's visible rectangle.
useLayoutEffect(() => {
// Initialize viewportRect before updating size for the first time.
state.viewportSize = new Size(window.innerWidth, window.innerHeight);
Copy link
Contributor

@nwidynski nwidynski Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This includes scrollbars. We might want to use document.scrollingElement.clientWidth/Height? Or was there specific intent to keep it tied to the window object for test environments?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's simpler to mock, and accuracy is not really important here because this is simply an upper-bound on the number of items to display.

style.overflowX = 'auto';
style.overflowY = 'hidden';
} else if (scrollDirection === 'vertical' || contentSize.width === state.width) {
} else if (scrollDirection === 'vertical' || contentSize.width === state.size.width) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated, but a question I raised in #9115. Do you still happen to carry reproduction steps for this happening? I couldn't see this issue in my testing after refactoring.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like it was added in #5380

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I found the specific comment too, but could not reproduce the flickering that prompted this change anymore. @LFDanLu Do you still have notes on the specific reproduction steps?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oof, I do not unfortunately, but if memory serves it would happen when stepping through various browser zoom levels.

// If scrolling is not currently prevented then we aren't in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view
// Also ignore in chrome because of this bug: https://issues.chromium.org/issues/40074749
if (!isScrollPrevented && !isChrome()) {
if (!isScrollPrevented) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, why did the shim not work correctly there? Could maybe be addressed in #9780?

<Context.Provider value={{state, dropState}}>
<Virtualizer
{...mergeProps(collectionProps, listBoxProps)}
onScroll={undefined}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC, this could be a breaking change in the way its handled here? Before, we invoked the callback twice actually, once with a native event and once in our callback. Now the user wouldn't be receiving a UIEvent instance anymore.


let updateVisibleRect = useCallback(() => {
// Intersect the window viewport with the scroll view itself to find the actual visible rectangle.
// This allows virtualized components to have unbounded height but still virtualize when scrolled with the page.
Copy link
Contributor

@nwidynski nwidynski Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Btw, the VIRT_ON flag breaks in certain scenarios. I addressed these cases with further improvements specific to virtualizer in test environments within #9115.

Copy link
Member

@snowystinger snowystinger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to get this in for testing, whatever follow ups can be done separately

Copy link
Member

@reidbarber reidbarber left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving for testing, happy for anything else to be follow-up.

Comment on lines +100 to +105
let visibleRect = new Rect(
state.viewportOffset.x + state.scrollPosition.x,
state.viewportOffset.y + state.scrollPosition.y,
Math.max(0, Math.min(state.size.width - state.viewportOffset.x, state.viewportSize.width)),
Math.max(0, Math.min(state.size.height - state.viewportOffset.y, state.viewportSize.height))
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this accurate if the virtualizer is below the fold? I'm wondering if it would get a full-viewport visibleRect instead of a zero-height one.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes I think so. it takes the minimum between the height - viewport offset (which will be negative), and the viewport height.

Comment on lines +181 to +185
// Attach a document-level capturing scroll listener so we can account for scrollable ancestors.
useEffect(() => {
document.addEventListener('scroll', onScroll, true);
return () => document.removeEventListener('scroll', onScroll, true);
}, [onScroll]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does useLoadMore need updating? It looks at ref.current.scrollTop/clientHeight/scrollHeight, which would be the virtualizer element itself.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm yeah good point. This would only affect v3 I think. RAC uses useLoadMoreSentinel which uses an intersection observer instead, so that should work regardless of the scroll container. So I guess depends if we want to support this for v3.

@devongovett devongovett added this pull request to the merge queue Mar 17, 2026
return;
}

state.viewportOffset = new Point(x, y);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit unfortunate that scrolling the table viewport scroll story will scroll the row that is out of view to the top of the viewport rather than doing just enough to bring the row into view at the bottom of the screen. It seems to be due to scrollIntoView which really should only be for centering the entire table into view if it was partially/completely out of view but probably shouldn't happen if the viewport itself is the scroll container... Not an issue with this PR, but maybe there is something we can do to address this

Merged via the queue into main with commit daa9d7b Mar 17, 2026
29 checks passed
@devongovett devongovett deleted the virtualizer-window branch March 17, 2026 23:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RAC] Table/Virtualizer: Allow ancestor scrolling element Feature request: Window scrolling for Virtualizer

6 participants